Наша задача — провести оценку результатов A/B-теста.
В нашем распоряжении есть датасет с действиями пользователей, техническое задание и несколько вспомогательных датасетов.
Чтобы оценить корректность проведения теста, проверем:
recommender_system_test;product_page,product_cart,purchase.Входные данный
final_ab_events.csv — действия новых пользователей
ab_project_marketing_events.csv — календарь маркетинговых событий на 2020 г
final_ab_new_users.csv — пользователи, зарегистрировавшиеся с 7 по 21 декабря 2020 г
final_ab_participants.csv — таблица участников тестов.
pandas - для загрузки и обработки данных мы воспльзуемся библеотекой .
numpy - библиотека высокоуровневых математических функций
scipy - библиотека математического и числового анализов
datetime - библиотека для работой с датой
re - модуль для регулярных выражений
seaborn - библиотека для создания статистических графиков
matplotlib.pyplot - библиотека для работы с графиками
plotly - библиотека визуализации данных (для воронкообразных диаграмм)
import pandas as pd
import numpy as np
from scipy import stats as st
from datetime import datetime as dt, timedelta
import re
import seaborn as sns
import matplotlib.pyplot as plt
import plotly.graph_objects as go
def pre_check(df):
display(df.head(5)) # вывод первых 5 строк
display('Количество пустых ячеек:', df.isna().sum()) # количество пустых ячеек
print('Количество полных дубликатов:', df.duplicated().sum()) # количество абсолютных дубликатов
print()
df.info() # общая информация
Загрузим файлы для проекта:
#список файлов:
list_name_csv =[
'final_ab_events.csv',
'ab_project_marketing_events.csv',
'final_ab_new_users.csv',
'final_ab_participants.csv'
]
#путь онлайн
pth_online = 'https://*****'
try:
for i in range(len(list_name_csv)):
name_p = re.match(r'^[^.]*', list_name_csv[i])[0]#берем имя до точки
path = pth_online + list_name_csv[i]#собираем путь к файлу
exec(f'{name_p} = pd.read_csv(path)')#создаем новую переменную с данными
display('Загрузка онлайн')
except FileNotFoundError:
for i in range(len(list_name_csv)):
name_p = re.match(r'^[^.]*', list_name_csv[i])[0]#берем имя до точки
path = list_name_csv[i]#собираем путь к файлу
exec(f'{name_p} = pd.read_csv(path)')#создаем новую переменную с данными
display('Загрузка локально')
except Exception:
display('Файлы отсутствуют и онлайн и локально')
'Загрузка онлайн'
pre_check(final_ab_events)
| user_id | event_dt | event_name | details | |
|---|---|---|---|---|
| 0 | E1BDDCE0DAFA2679 | 2020-12-07 20:22:03 | purchase | 99.99 |
| 1 | 7B6452F081F49504 | 2020-12-07 09:22:53 | purchase | 9.99 |
| 2 | 9CD9F34546DF254C | 2020-12-07 12:59:29 | purchase | 4.99 |
| 3 | 96F27A054B191457 | 2020-12-07 04:02:40 | purchase | 4.99 |
| 4 | 1FD7660FDF94CA1F | 2020-12-07 10:15:09 | purchase | 4.99 |
'Количество пустых ячеек:'
user_id 0 event_dt 0 event_name 0 details 377577 dtype: int64
Количество полных дубликатов: 0 <class 'pandas.core.frame.DataFrame'> RangeIndex: 440317 entries, 0 to 440316 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 440317 non-null object 1 event_dt 440317 non-null object 2 event_name 440317 non-null object 3 details 62740 non-null float64 dtypes: float64(1), object(3) memory usage: 13.4+ MB
pre_check(ab_project_marketing_events)
| name | regions | start_dt | finish_dt | |
|---|---|---|---|---|
| 0 | Christmas&New Year Promo | EU, N.America | 2020-12-25 | 2021-01-03 |
| 1 | St. Valentine's Day Giveaway | EU, CIS, APAC, N.America | 2020-02-14 | 2020-02-16 |
| 2 | St. Patric's Day Promo | EU, N.America | 2020-03-17 | 2020-03-19 |
| 3 | Easter Promo | EU, CIS, APAC, N.America | 2020-04-12 | 2020-04-19 |
| 4 | 4th of July Promo | N.America | 2020-07-04 | 2020-07-11 |
'Количество пустых ячеек:'
name 0 regions 0 start_dt 0 finish_dt 0 dtype: int64
Количество полных дубликатов: 0 <class 'pandas.core.frame.DataFrame'> RangeIndex: 14 entries, 0 to 13 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 name 14 non-null object 1 regions 14 non-null object 2 start_dt 14 non-null object 3 finish_dt 14 non-null object dtypes: object(4) memory usage: 576.0+ bytes
pre_check(final_ab_new_users)
| user_id | first_date | region | device | |
|---|---|---|---|---|
| 0 | D72A72121175D8BE | 2020-12-07 | EU | PC |
| 1 | F1C668619DFE6E65 | 2020-12-07 | N.America | Android |
| 2 | 2E1BF1D4C37EA01F | 2020-12-07 | EU | PC |
| 3 | 50734A22C0C63768 | 2020-12-07 | EU | iPhone |
| 4 | E1BDDCE0DAFA2679 | 2020-12-07 | N.America | iPhone |
'Количество пустых ячеек:'
user_id 0 first_date 0 region 0 device 0 dtype: int64
Количество полных дубликатов: 0 <class 'pandas.core.frame.DataFrame'> RangeIndex: 61733 entries, 0 to 61732 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 61733 non-null object 1 first_date 61733 non-null object 2 region 61733 non-null object 3 device 61733 non-null object dtypes: object(4) memory usage: 1.9+ MB
pre_check(final_ab_participants)
| user_id | group | ab_test | |
|---|---|---|---|
| 0 | D1ABA3E2887B6A73 | A | recommender_system_test |
| 1 | A7A3664BD6242119 | A | recommender_system_test |
| 2 | DABC14FDDFADD29E | A | recommender_system_test |
| 3 | 04988C5DF189632E | A | recommender_system_test |
| 4 | 482F14783456D21B | B | recommender_system_test |
'Количество пустых ячеек:'
user_id 0 group 0 ab_test 0 dtype: int64
Количество полных дубликатов: 0 <class 'pandas.core.frame.DataFrame'> RangeIndex: 18268 entries, 0 to 18267 Data columns (total 3 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 18268 non-null object 1 group 18268 non-null object 2 ab_test 18268 non-null object dtypes: object(3) memory usage: 428.3+ KB
Данные загрузились корректно переходи к предобработке.
на предварительном просмотре выявелись следущие замечания:
final_ab_events поле details - дополнительные данные о событии (377577 ячейки)посмотрим на пустые ячейки подробнее,
какие события с не пустыми значениями:
final_ab_events[final_ab_events['details'].notna()]['event_name'].value_counts()
purchase 62740 Name: event_name, dtype: int64
purchase запонеными данными в колонке 'details', посмотрим что с пустыми значениями:
final_ab_events[final_ab_events['details'].isna()]['event_name'].value_counts()
login 189552 product_page 125563 product_cart 62462 Name: event_name, dtype: int64
Понятно что колонка details это только стоимость покупки, при других событиях она пустая, будем иметь это ввиду.
напишем функцию для приведения колонок с датой к соответствующему типу:
def data_type(df,shape,col=[]):
for i in col:
df[i] = pd.to_datetime(df[i], format='%Y-%m-%dT%H:%M:%S') #.map(lambda x: dt.strptime(x, shape))
print(i,'тип данных:',df[i].dtypes)
return df
применим к колонкам с датой:
data_type(final_ab_events,'%Y-%m-%dT%H:%M:%S',col=['event_dt'])
data_type(ab_project_marketing_events,'%Y-%m-%d',col=['start_dt','finish_dt'])
data_type(final_ab_new_users,'%Y-%m-%dT%H:%M:%S',col=['first_date'])
#добавляем колонку с датой без времени
final_ab_events['dt'] = pd.to_datetime(final_ab_events['event_dt']).dt.date
#добавляем колонку с днем недели
final_ab_events['week_day'] = pd.to_datetime(final_ab_events['event_dt']).dt.dayofweek
display(final_ab_events.head())
display(ab_project_marketing_events.head())
display(final_ab_new_users.head())
event_dt тип данных: datetime64[ns] start_dt тип данных: datetime64[ns] finish_dt тип данных: datetime64[ns] first_date тип данных: datetime64[ns]
| user_id | event_dt | event_name | details | dt | week_day | |
|---|---|---|---|---|---|---|
| 0 | E1BDDCE0DAFA2679 | 2020-12-07 20:22:03 | purchase | 99.99 | 2020-12-07 | 0 |
| 1 | 7B6452F081F49504 | 2020-12-07 09:22:53 | purchase | 9.99 | 2020-12-07 | 0 |
| 2 | 9CD9F34546DF254C | 2020-12-07 12:59:29 | purchase | 4.99 | 2020-12-07 | 0 |
| 3 | 96F27A054B191457 | 2020-12-07 04:02:40 | purchase | 4.99 | 2020-12-07 | 0 |
| 4 | 1FD7660FDF94CA1F | 2020-12-07 10:15:09 | purchase | 4.99 | 2020-12-07 | 0 |
| name | regions | start_dt | finish_dt | |
|---|---|---|---|---|
| 0 | Christmas&New Year Promo | EU, N.America | 2020-12-25 | 2021-01-03 |
| 1 | St. Valentine's Day Giveaway | EU, CIS, APAC, N.America | 2020-02-14 | 2020-02-16 |
| 2 | St. Patric's Day Promo | EU, N.America | 2020-03-17 | 2020-03-19 |
| 3 | Easter Promo | EU, CIS, APAC, N.America | 2020-04-12 | 2020-04-19 |
| 4 | 4th of July Promo | N.America | 2020-07-04 | 2020-07-11 |
| user_id | first_date | region | device | |
|---|---|---|---|---|
| 0 | D72A72121175D8BE | 2020-12-07 | EU | PC |
| 1 | F1C668619DFE6E65 | 2020-12-07 | N.America | Android |
| 2 | 2E1BF1D4C37EA01F | 2020-12-07 | EU | PC |
| 3 | 50734A22C0C63768 | 2020-12-07 | EU | iPhone |
| 4 | E1BDDCE0DAFA2679 | 2020-12-07 | N.America | iPhone |
хорошо, посмотри на аномалии кол-ва событий на одного пользователя:
final_ab_events.groupby('user_id').agg({'event_name':'count'}).hist(
bins=40,
figsize=(10,5),
grid=True);
plt.title('График количество событий на одного пользователя')# Заголовок графика
# Добавляем подписи к осям:
plt.xlabel('Кол-во событий')
plt.ylabel('Кол-во пользователей');
Есть выбросы начиная примерно с 20 событий.
Посчитаем 95-й и 99-й перцентили количества событий на пользователя и выберем границу для определения аномальных пользователей.
np.percentile(final_ab_events.groupby('user_id').agg({'event_name':'count'}), [95, 99])
array([15., 20.])
20 событий на человека включают 99% пользователей.
Убирем аномальных пользователей.:
abnormal_users = (
final_ab_events.groupby('user_id').agg({'event_name':'count'})
.reset_index()
)
# находим пользователей у которых количечтво событий превышает 99% и сохраняем их ID
abnormal_users = (abnormal_users[abnormal_users['event_name']> np.percentile(abnormal_users['event_name'], 99)]
.sort_values('event_name')['user_id'])
# убираем аномальных пользователей из таблицы
final_ab_events_normal = final_ab_events[~final_ab_events['user_id'].isin(abnormal_users)]
print('Количество пользователей:', len(final_ab_events_normal['user_id'].unique()))
final_ab_events_normal.info()
Количество пользователей: 58182 <class 'pandas.core.frame.DataFrame'> Int64Index: 428101 entries, 0 to 440316 Data columns (total 6 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 428101 non-null object 1 event_dt 428101 non-null datetime64[ns] 2 event_name 428101 non-null object 3 details 60078 non-null float64 4 dt 428101 non-null object 5 week_day 428101 non-null int64 dtypes: datetime64[ns](1), float64(1), int64(1), object(3) memory usage: 22.9+ MB
Остается 428 101 события из 440 317
напишем небельшую функцию для вывода временного среза(минимальную и максимальную дату):
def print_max_min_date(df,name,col=[]):
print(name)
for i in col:
print('min:',df[i].min())
print('max:',df[i].max())
проверим срез времени на соответсвие ТЗ:
в ТЗ указанно: дата запуска: 2020-12-07, дата остановки набора новых пользователей: 2020-12-21, дата остановки: 2021-01-04
min_date = '2020-12-07'
max_date_user = '2020-12-21'
max_date = '2021-01-04'
final_ab_new_users = final_ab_new_users.query('@min_date <= first_date <= @max_date_user')
final_ab_events = final_ab_events.query('@min_date <= event_dt <= @max_date')
print_max_min_date(final_ab_events,'Действия новых пользователей',col=['event_dt'])
Действия новых пользователей min: 2020-12-07 00:00:33 max: 2020-12-30 23:36:33
таблица "Действия новых пользователей" дата запуска соответствует ТЗ, а вот дата остановки на 5 дней короче чем в ТЗ (2021-01-04 - 2020-12-30)
print_max_min_date(
ab_project_marketing_events.query('@min_date <= start_dt <= @max_date'),
'Календарь маркетинговых событий',
col=['start_dt','finish_dt'])
Календарь маркетинговых событий min: 2020-12-25 00:00:00 max: 2020-12-30 00:00:00 min: 2021-01-03 00:00:00 max: 2021-01-07 00:00:00
Данные по маркетинговым событиям ближайшая 25 декабря у нас набор закончился 21 декабря, позже на всякий случай посмотрим на пересечения мероприятий и нашего отрезка подробнее.
print_max_min_date(
final_ab_new_users,
'пользователи, зарегистрировавшиеся с 7 по 21 декабря 2020',
col=['first_date'])
пользователи, зарегистрировавшиеся с 7 по 21 декабря 2020 min: 2020-12-07 00:00:00 max: 2020-12-21 00:00:00
по ТЗ дата остановки 21 декабря все соответствует ТЗ, но нужно объеденить базы чтобы проверить что при объеденении часять данных отфильтруется по наличию данных пользователей в обеих базах:
tests_events = final_ab_participants.query('ab_test=="recommender_system_test"').merge(final_ab_new_users, on='user_id')
print_max_min_date(
tests_events,
'пользователи, зарегистрировавшиеся с 7 по 21 декабря 2020',
col=['first_date'])
пользователи, зарегистрировавшиеся с 7 по 21 декабря 2020 min: 2020-12-07 00:00:00 max: 2020-12-21 00:00:00
хорошо временной срез соответствует техническому заданию.
Посмотрим на проводимые тесты в нашей базе и кол-во пользователей:
final_ab_participants['ab_test'].value_counts()
interface_eu_test 11567 recommender_system_test 6701 Name: ab_test, dtype: int64
при предварительном просмотре наш тест recommender_system_test в соответсвии с ТЗ набрал свыше 6000 (6701),
посмотрим как пользователи распределяется по группам в тестах.
agg_ab = final_ab_participants.groupby(['ab_test','group']).agg({'user_id':'count'}).reset_index()
agg_ab
| ab_test | group | user_id | |
|---|---|---|---|
| 0 | interface_eu_test | A | 5831 |
| 1 | interface_eu_test | B | 5736 |
| 2 | recommender_system_test | A | 3824 |
| 3 | recommender_system_test | B | 2877 |
в нашем тесте recommender_system_test видем не равномерное распределение, а вот второй тест распределился почти равномерно.
расчитаем как распределился наш тест в процентном соотношении:
agg_ab_rst = agg_ab.query('ab_test=="recommender_system_test"').reset_index(drop=True)
ab_rst_sum = agg_ab_rst['user_id'].sum()
agg_ab_rst['ratio_rst'] = round(agg_ab_rst['user_id']/ab_rst_sum*100)
agg_ab_rst
| ab_test | group | user_id | ratio_rst | |
|---|---|---|---|---|
| 0 | recommender_system_test | A | 3824 | 57.0 |
| 1 | recommender_system_test | B | 2877 | 43.0 |
Видим что в группе на ~7% больше чем в группе В, существенное различие в ~474 пользователя,
предварительно не понятно почему распределение прошло не равномерно нужно посмотреть дополнительные параметры пользователя, тип устройства и регион.
возможно есть где-то провал что в каком то регионе и/или устройстве было четкий перекос в одну из групп. Тогда можно сказать что убрав его мы сможем "выровнять" группы а организаторам теста указать на место где распределение пошло не по плану.
расмотрим в регионах прошло распределение:
def ratio_param(df):
df_c = df.copy()
for i in range(len(df_c.columns)):
df_c[df_c.columns[i]] = round(df_c[df_c.columns[i]] / (df_c[df_c.columns[i]].sum()) * 100)
return df_c
def revision_ab(df, col=[]):
group = df['group'].value_counts().reset_index()
group['ratio'] = round(group['group'] / df.shape[0] * 100)
print('Распределение по группам')
display(group)
for i in col:
print('Процентное соотношение колонки', i,':')
display(round(df[i].value_counts()/df.shape[0]*100).reset_index())
print()
cr_df = df.pivot_table(index='group', columns=i, values='user_id', aggfunc='count')
print('Распределение колонки',i,'распределение по группам:')
display(cr_df)
print('Процентное соотношение колонки',i,'распределение по группам:')
display(ratio_param(cr_df))
all_df = df.pivot_table(index='group', columns=col, values='user_id', aggfunc='count')
print('Распределение колонок',', '.join(col),'распределение по группам:')
display(all_df)
print('Процентное соотношение колонок',', '.join(col),'распределение по группам:')
display(ratio_param(all_df))
revision_ab_rst = final_ab_participants.query('ab_test=="recommender_system_test"').merge(final_ab_new_users, on='user_id')
revision_ab(revision_ab_rst, col=['region','device'])
Распределение по группам
| index | group | ratio | |
|---|---|---|---|
| 0 | A | 3824 | 57.0 |
| 1 | B | 2877 | 43.0 |
Процентное соотношение колонки region :
| index | region | |
|---|---|---|
| 0 | EU | 95.0 |
| 1 | N.America | 3.0 |
| 2 | APAC | 1.0 |
| 3 | CIS | 1.0 |
Распределение колонки region распределение по группам:
| region | APAC | CIS | EU | N.America |
|---|---|---|---|---|
| group | ||||
| A | 37 | 25 | 3634 | 128 |
| B | 35 | 30 | 2717 | 95 |
Процентное соотношение колонки region распределение по группам:
| region | APAC | CIS | EU | N.America |
|---|---|---|---|---|
| group | ||||
| A | 51.0 | 45.0 | 57.0 | 57.0 |
| B | 49.0 | 55.0 | 43.0 | 43.0 |
Процентное соотношение колонки device :
| index | device | |
|---|---|---|
| 0 | Android | 45.0 |
| 1 | PC | 25.0 |
| 2 | iPhone | 21.0 |
| 3 | Mac | 9.0 |
Распределение колонки device распределение по группам:
| device | Android | Mac | PC | iPhone |
|---|---|---|---|---|
| group | ||||
| A | 1672 | 373 | 1011 | 768 |
| B | 1311 | 260 | 696 | 610 |
Процентное соотношение колонки device распределение по группам:
| device | Android | Mac | PC | iPhone |
|---|---|---|---|---|
| group | ||||
| A | 56.0 | 59.0 | 59.0 | 56.0 |
| B | 44.0 | 41.0 | 41.0 | 44.0 |
Распределение колонок region, device распределение по группам:
| region | APAC | CIS | EU | N.America | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| device | Android | Mac | PC | iPhone | Android | Mac | PC | iPhone | Android | Mac | PC | iPhone | Android | Mac | PC | iPhone |
| group | ||||||||||||||||
| A | 18.0 | 5.0 | 11.0 | 3.0 | 10.0 | 2.0 | 6.0 | 7.0 | 1590.0 | 354.0 | 964.0 | 726.0 | 54.0 | 12.0 | 30.0 | 32.0 |
| B | 22.0 | NaN | 7.0 | 6.0 | 12.0 | 1.0 | 13.0 | 4.0 | 1228.0 | 250.0 | 657.0 | 582.0 | 49.0 | 9.0 | 19.0 | 18.0 |
Процентное соотношение колонок region, device распределение по группам:
| region | APAC | CIS | EU | N.America | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| device | Android | Mac | PC | iPhone | Android | Mac | PC | iPhone | Android | Mac | PC | iPhone | Android | Mac | PC | iPhone |
| group | ||||||||||||||||
| A | 45.0 | 100.0 | 61.0 | 33.0 | 45.0 | 67.0 | 32.0 | 64.0 | 56.0 | 59.0 | 59.0 | 56.0 | 52.0 | 57.0 | 61.0 | 64.0 |
| B | 55.0 | NaN | 39.0 | 67.0 | 55.0 | 33.0 | 68.0 | 36.0 | 44.0 | 41.0 | 41.0 | 44.0 | 48.0 | 43.0 | 39.0 | 36.0 |
Наблюдаем что новые пользователи для нашего теста преимущественно из Европы 95%.
Видим что распределение:
Это с учетом того что в Европе 95 % всех новых пользователей.
Распределение на устройства:
Во всех типах перекос в сторону группы А, и так же нет пустых чтобы подтвердить нашу теорию.
К сожаление точно установить где идет сбой не удалось,
Проверим как распределены данные в другом тесте:
revision_ab(
final_ab_participants.query('ab_test!="recommender_system_test"')
.merge(final_ab_new_users, on='user_id')
, col=['region','device']
)
Распределение по группам
| index | group | ratio | |
|---|---|---|---|
| 0 | A | 5342 | 51.0 |
| 1 | B | 5223 | 49.0 |
Процентное соотношение колонки region :
| index | region | |
|---|---|---|
| 0 | EU | 100.0 |
Распределение колонки region распределение по группам:
| region | EU |
|---|---|
| group | |
| A | 5342 |
| B | 5223 |
Процентное соотношение колонки region распределение по группам:
| region | EU |
|---|---|
| group | |
| A | 51.0 |
| B | 49.0 |
Процентное соотношение колонки device :
| index | device | |
|---|---|---|
| 0 | Android | 45.0 |
| 1 | PC | 26.0 |
| 2 | iPhone | 19.0 |
| 3 | Mac | 11.0 |
Распределение колонки device распределение по группам:
| device | Android | Mac | PC | iPhone |
|---|---|---|---|---|
| group | ||||
| A | 2353 | 546 | 1395 | 1048 |
| B | 2358 | 565 | 1307 | 993 |
Процентное соотношение колонки device распределение по группам:
| device | Android | Mac | PC | iPhone |
|---|---|---|---|---|
| group | ||||
| A | 50.0 | 49.0 | 52.0 | 51.0 |
| B | 50.0 | 51.0 | 48.0 | 49.0 |
Распределение колонок region, device распределение по группам:
| region | EU | |||
|---|---|---|---|---|
| device | Android | Mac | PC | iPhone |
| group | ||||
| A | 2353 | 546 | 1395 | 1048 |
| B | 2358 | 565 | 1307 | 993 |
Процентное соотношение колонок region, device распределение по группам:
| region | EU | |||
|---|---|---|---|---|
| device | Android | Mac | PC | iPhone |
| group | ||||
| A | 50.0 | 49.0 | 52.0 | 51.0 |
| B | 50.0 | 51.0 | 48.0 | 49.0 |
В этом тесте разделение почти идеальное 51 / 49 %.
interface_eu_test.соберем всех user_id в свою группу и провери их пересечение:
rst_A = final_ab_participants.query('ab_test=="recommender_system_test" and group == "A"').user_id.unique()
rst_B = final_ab_participants.query('ab_test=="recommender_system_test" and group == "B"').user_id.unique()
iet_A = final_ab_participants.query('ab_test=="interface_eu_test" and group == "A"').user_id.unique()
iet_B = final_ab_participants.query('ab_test=="interface_eu_test" and group == "B"').user_id.unique()
пересечение тестов:
list_tests = [rst_A,rst_B,iet_A,iet_B]
list_tests_name = ['rst_A','rst_B','iet_A','iet_B']
intersection_tests = pd.DataFrame(list_tests_name, columns = ['name'])
for i in range(len(list_tests)):
list_num = []
for j in range(len(list_tests)):
list_num.append(len(list(set(list_tests[i]) & set(list_tests[j]))))
intersection_tests[list_tests_name[i]] = list_num
intersection_tests = intersection_tests.set_index('name')
intersection_tests
| rst_A | rst_B | iet_A | iet_B | |
|---|---|---|---|---|
| name | ||||
| rst_A | 3824 | 0 | 482 | 439 |
| rst_B | 0 | 2877 | 337 | 344 |
| iet_A | 482 | 337 | 5831 | 0 |
| iet_B | 439 | 344 | 0 | 5736 |
Видим что внутри тестов пересечений нет, а вот между собой они все пересекаются то есть пересекаются и контрольные группы и тестируемые.
rst_Aiet_A 482 пользователя iet_В 439 пользователяrst_Biet_A 337 пользователя iet_В 344 пользователясоберем пересечения и дополним в нашу таблицу:
intersection_iet = [
len(list(set(rst_A) & set(final_ab_participants.query('ab_test=="interface_eu_test"').user_id.unique()))),
len(list(set(rst_B) & set(final_ab_participants.query('ab_test=="interface_eu_test"').user_id.unique())))
]
agg_ab_rst['intersection_iet'] = intersection_iet
agg_ab_rst['ratio_iet'] = round(agg_ab_rst['intersection_iet']/sum(intersection_iet)*100)
agg_ab_rst
| ab_test | group | user_id | ratio_rst | intersection_iet | ratio_iet | |
|---|---|---|---|---|---|---|
| 0 | recommender_system_test | A | 3824 | 57.0 | 921 | 57.0 |
| 1 | recommender_system_test | B | 2877 | 43.0 | 681 | 43.0 |
Видим что пересечение распределилось равномерно относительно деления нашего теста. 57 / 43 %
при этом видим что пересечение котрольноль группа А и контрольной группы
возьмем это к сведению, продолжим проверять все пунты ТЗ
rst = final_ab_participants.query('ab_test=="recommender_system_test"').drop(columns='ab_test')
rst.head()
| user_id | group | |
|---|---|---|
| 0 | D1ABA3E2887B6A73 | A |
| 1 | A7A3664BD6242119 | A |
| 2 | DABC14FDDFADD29E | A |
| 3 | 04988C5DF189632E | A |
| 4 | 482F14783456D21B | B |
выберем данные по нашим условиям:
fabnu_crop_rst = final_ab_new_users.query('user_id in @rst.user_id.unique()')
print('Новые пользователи и новые пользователи для нашего теста',final_ab_new_users.shape[0],'>',fabnu_crop_rst.shape[0])
fabnu_crop_rst.head()
Новые пользователи и новые пользователи для нашего теста 56470 > 6701
| user_id | first_date | region | device | |
|---|---|---|---|---|
| 0 | D72A72121175D8BE | 2020-12-07 | EU | PC |
| 13 | E6DE857AFBDC6102 | 2020-12-07 | EU | PC |
| 20 | DD4352CDCF8C3D57 | 2020-12-07 | EU | Android |
| 23 | 831887FE7F2D6CBA | 2020-12-07 | EU | Android |
| 39 | 4CB179C7F847320B | 2020-12-07 | EU | iPhone |
auditorium = final_ab_new_users['region'].value_counts().reset_index()
auditorium.columns = ['region','all_users']
auditorium['rst_test'] = fabnu_crop_rst.query('@min_date <= first_date <= @max_date_user')['region'].value_counts().to_list()
auditorium['ratio'] = round(auditorium['rst_test']/auditorium['all_users']*100,2)
auditorium
| region | all_users | rst_test | ratio | |
|---|---|---|---|---|
| 0 | EU | 42340 | 6351 | 15.00 |
| 1 | N.America | 8347 | 223 | 2.67 |
| 2 | CIS | 2900 | 72 | 2.48 |
| 3 | APAC | 2883 | 55 | 1.91 |
аудитория EU увеличилась на ожидаемые 15%
объеденим данные для нашего исследования:
final_ab_events_crop = final_ab_events.query('user_id in @rst.user_id and event_dt < "2020-12-29"')
print(final_ab_events.shape[0],'>',final_ab_events_crop.shape[0])
print(final_ab_events_crop.user_id.nunique())
final_ab_events_crop = final_ab_events_crop.merge(rst, on='user_id')
final_ab_events_crop = final_ab_events_crop.merge(fabnu_crop_rst, on='user_id')
final_ab_events_crop.head()
440317 > 24186 3675
| user_id | event_dt | event_name | details | dt | week_day | group | first_date | region | device | |
|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 831887FE7F2D6CBA | 2020-12-07 06:50:29 | purchase | 4.99 | 2020-12-07 | 0 | A | 2020-12-07 | EU | Android |
| 1 | 831887FE7F2D6CBA | 2020-12-09 02:19:17 | purchase | 99.99 | 2020-12-09 | 2 | A | 2020-12-07 | EU | Android |
| 2 | 831887FE7F2D6CBA | 2020-12-07 06:50:30 | product_cart | NaN | 2020-12-07 | 0 | A | 2020-12-07 | EU | Android |
| 3 | 831887FE7F2D6CBA | 2020-12-08 10:52:27 | product_cart | NaN | 2020-12-08 | 1 | A | 2020-12-07 | EU | Android |
| 4 | 831887FE7F2D6CBA | 2020-12-09 02:19:17 | product_cart | NaN | 2020-12-09 | 2 | A | 2020-12-07 | EU | Android |
событий осталось всего 24 186.
пользователей всего 3 675 , что гораздо меньше 6 000 по ТЗ.
видимо у нас много не активных пользователей (пользователь зарегистрировался, а затем не совершил не одного действия или были проблемы со входом), надо взять на заметку, и сообщить разработчика что-то пошло не так.
добавим их к таблице аудитории:
auditorium['rst_test_crop'] = (
final_ab_events_crop.groupby(['user_id','region'])
.agg({'group':'min'}).reset_index()['region']
.value_counts().to_list()
)
auditorium['ratio_crop'] = round(auditorium['rst_test_crop']/auditorium['all_users']*100,2)
auditorium
| region | all_users | rst_test | ratio | rst_test_crop | ratio_crop | |
|---|---|---|---|---|---|---|
| 0 | EU | 42340 | 6351 | 15.00 | 3481 | 8.22 |
| 1 | N.America | 8347 | 223 | 2.67 | 119 | 1.43 |
| 2 | CIS | 2900 | 72 | 2.48 | 45 | 1.55 |
| 3 | APAC | 2883 | 55 | 1.91 | 30 | 1.04 |
после всех фильтраций осталось всего 8,2% новых пользователей из Европы.
так же подготовим данные для контрольного сравнения без пользователей участвовавших в тесте:
final_not_ab_events_crop = final_ab_events.query('user_id not in @rst.user_id and event_dt < "2020-12-29"')
print(final_ab_events.shape[0],'>',final_not_ab_events_crop.shape[0])
print(final_not_ab_events_crop.user_id.nunique())
final_not_ab_events_crop = final_not_ab_events_crop.merge(final_ab_new_users, on='user_id')
final_not_ab_events_crop.head()
440317 > 405896 55027
| user_id | event_dt | event_name | details | dt | week_day | first_date | region | device | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | E1BDDCE0DAFA2679 | 2020-12-07 20:22:03 | purchase | 99.99 | 2020-12-07 | 0 | 2020-12-07 | N.America | iPhone |
| 1 | E1BDDCE0DAFA2679 | 2020-12-09 06:21:35 | purchase | 9.99 | 2020-12-09 | 2 | 2020-12-07 | N.America | iPhone |
| 2 | E1BDDCE0DAFA2679 | 2020-12-25 08:26:03 | purchase | 499.99 | 2020-12-25 | 4 | 2020-12-07 | N.America | iPhone |
| 3 | E1BDDCE0DAFA2679 | 2020-12-07 20:22:03 | login | NaN | 2020-12-07 | 0 | 2020-12-07 | N.America | iPhone |
| 4 | E1BDDCE0DAFA2679 | 2020-12-09 06:21:35 | login | NaN | 2020-12-09 | 2 | 2020-12-07 | N.America | iPhone |
событий осталось всего 405 896.
пользователей всего 55 027
посмотрим на количество событий:
final_ab_events_crop['event_name'].value_counts().reset_index()
| index | event_name | |
|---|---|---|
| 0 | login | 10949 |
| 1 | product_page | 6777 |
| 2 | purchase | 3275 |
| 3 | product_cart | 3185 |
round(final_ab_events_crop['event_name'].value_counts()/final_ab_events_crop.shape[0]*100,2).reset_index()
| index | event_name | |
|---|---|---|
| 0 | login | 45.27 |
| 1 | product_page | 28.02 |
| 2 | purchase | 13.54 |
| 3 | product_cart | 13.17 |
Вот как распределены данные по событиям:
login - залогинились - 45% - 10 949product_page - страница продукта - 28% - 6 777product_cart - страница корзины - 13,5% - 3 275purchase - покупка - 13% - 3 185Напишем функцию для формировании в группах и отображении воронки:
def funnel_through(df, u_id, event_name, date_time, list_events, list_events_text, title, through=False, img=False):
df = df.sort_values(['user_id','event_dt'])
#создаем сводную таблицу
step_data_pivot = df.pivot_table(index=u_id, #user_id, devaice_id ... _id
columns=event_name, #event_name
values=date_time, #event_date
aggfunc='min')
counts_step = len(list_events)#определяем кол-во шагов
steps = {}#создаем словарь шагов
steps[0] = ~step_data_pivot[list_events[0]].isna()#первый шаг воронки проверка на не пустое значение
if through:
for i in range(0,len(list_events)-1):#остальные шаги
steps[i+1] = steps[0] & (~step_data_pivot[list_events[i+1]].isna())
#проверяем True предыдущего шага и что есть два следущих события True и они идут друг за другом
else:
for i in range(0,len(list_events)-1):#остальные шаги
steps[i+1] = steps[i] & (step_data_pivot[list_events[i+1]] > step_data_pivot[list_events[i]])
#проверяем True предыдущего шага и что есть два следущих события True и они идут друг за другом
steps_sum = [sum(steps[x]) for x in steps.keys()]#считаем сумму
df_steps = pd.DataFrame(steps_sum, index=list_events_text, columns=['quantity']) #собираем в таблицу
df_steps['total'] = round(df_steps['quantity']/df_steps['quantity'].sum() * 100) #добавляем % от первого события
df_steps['first_step'] = round(df_steps['quantity']/df_steps['quantity'][0] * 100) #добавляем % от первого события
df_steps['steps'] = (round(df_steps['quantity'].pct_change(),2) + 1) * 100 #добавляем % от предыдущего шага
display(df_steps) #выводим таблицу можно сделать return если не нужна визуализация
if img:
#делаем график воронки
fig = go.Figure(go.Funnel(
y = df_steps.index,
x = df_steps['quantity']
))
fig.update_layout(title=title)
fig.show()
функция для отображения дней с добовление понедельников для удобства восприятия.
#подпись осей день + понедельник
def mon_ticks(dt_list):
day_ticks = []
for i in dt_list:
if i.strftime('%a') == 'Mon':
day_ticks.append(i.strftime('%d') + '\n' + i.strftime('%a'))
else:
day_ticks.append(i.strftime('%d'))
return plt.xticks(dt_list, day_ticks)
Создадим список нашей воронки:
list_events = ['login', 'product_page', 'product_cart', 'purchase']
применим нашу функцию для нашей тестируемой группы:
funnel_through(
final_ab_events_crop,
'user_id',
'event_name',
'event_dt',
list_events,
list_events,
'Сквозная воронка пользователей теста',
through=True,
img=True
)
| quantity | total | first_step | steps | |
|---|---|---|---|---|
| login | 3675 | 45.0 | 100.0 | NaN |
| product_page | 2303 | 28.0 | 63.0 | 63.0 |
| product_cart | 1079 | 13.0 | 29.0 | 47.0 |
| purchase | 1128 | 14.0 | 31.0 | 105.0 |
Вот как распределены данные по событиям:
login - залогинились - 45% - 3 675product_page - страница продукта - 28% - 2 303product_cart - страница корзины - 13% - 1 079purchase - покупка - 14% - 1 128от входа до покупки дошло 31% пользователей
Отдельно в группе В
funnel_through(
final_ab_events_crop.query('group == "A"'),
'user_id',
'event_name',
'event_dt',
list_events,
list_events,
'Сквозная воронка группы А',
through=True,
img=True
)
| quantity | total | first_step | steps | |
|---|---|---|---|---|
| login | 2747 | 44.0 | 100.0 | NaN |
| product_page | 1780 | 29.0 | 65.0 | 65.0 |
| product_cart | 824 | 13.0 | 30.0 | 46.0 |
| purchase | 872 | 14.0 | 32.0 | 106.0 |
Вот как распределены данные по событиям в группе А :
login - залогинились - 44% - 2 747product_page - страница продукта - 28% - 1 780product_cart - страница корзины - 13% - 824purchase - покупка - 14% - 872от входа до покупки дошло 32% пользователей
Отдельно в гуппе В
funnel_through(
final_ab_events_crop.query('group == "B"'),
'user_id',
'event_name',
'event_dt',
list_events,
list_events,
'Сквозная воронка группы B',
through=True,
img=True
)
| quantity | total | first_step | steps | |
|---|---|---|---|---|
| login | 928 | 47.0 | 100.0 | NaN |
| product_page | 523 | 27.0 | 56.0 | 56.0 |
| product_cart | 255 | 13.0 | 27.0 | 49.0 |
| purchase | 256 | 13.0 | 28.0 | 100.0 |
Вот как распределены данные по событиям в группе В :
login - залогинились - 47% - 928product_page - страница продукта - 27% - 523product_cart - страница корзины - 13% - 255 purchase - покупка - 13% - 256от входа до покупки дошло 28% пользователей
Дополнительно посорим воронку пользователей не участвовавших в тесте:
funnel_through(
final_not_ab_events_crop,
'user_id',
'event_name',
'event_dt',
list_events,
list_events,
'Сквозная воронка без теста',
through=True,
img=True
)
| quantity | total | first_step | steps | |
|---|---|---|---|---|
| login | 49757 | 43.0 | 100.0 | NaN |
| product_page | 33119 | 29.0 | 67.0 | 67.0 |
| product_cart | 16445 | 14.0 | 33.0 | 50.0 |
| purchase | 16647 | 14.0 | 33.0 | 101.0 |
final_not_ab_events_crop.user_id.nunique()
49764
Вот как распределены данные пользователей не участвовавших в тесте:
login - залогинились - 43% - 55 тыс.product_page - страница продукта - 29% - 37 тыс.product_cart - страница корзины - 14% - 18 тыс.purchase - покупка - 14% - 18 тыс.от входа до покупки дошло 34% пользователей
итог:
группа В на 4% хуже группы А в прохождения до покупки. Группа А ниже контрольной группы на 2%.
Силных отличий на этапах самой воронки между групп практически нет.
можно увидеть что не все этапы воронки обязательны, например product_cart тоесть можно совершить покупку не заходя обязатеьно в корзину.
Чуть подробнее посмотри на не дошедших до вхождения по своим аккаунтом пользователе (зарегистрированных, но не активных):
not_active_rst_count = [
len(list(set(rst_A)-set(final_ab_events_crop.user_id.unique()))),
len(list(set(rst_B)-set(final_ab_events_crop.user_id.unique())))
]
добавим эти данные в общею агрегированную таблицу по тесту:
agg_ab_rst['not_active_rst'] = not_active_rst_count
agg_ab_rst['ratio_not_active_rst'] = round(agg_ab_rst['not_active_rst']/sum(not_active_rst_count) * 100)
agg_ab_rst['active_rst'] = agg_ab_rst['user_id'] - agg_ab_rst['not_active_rst']
agg_ab_rst['active_rst_%'] = round(agg_ab_rst['active_rst'] / agg_ab_rst['user_id'] * 100)
print(agg_ab_rst['user_id'].sum(),'>',agg_ab_rst['not_active_rst'].sum())
print(round(agg_ab_rst['not_active_rst'].sum() / agg_ab_rst['user_id'].sum()*100),'%')
agg_ab_rst
6701 > 3026 45 %
| ab_test | group | user_id | ratio_rst | intersection_iet | ratio_iet | not_active_rst | ratio_not_active_rst | active_rst | active_rst_% | |
|---|---|---|---|---|---|---|---|---|---|---|
| 0 | recommender_system_test | A | 3824 | 57.0 | 921 | 57.0 | 1077 | 36.0 | 2747 | 72.0 |
| 1 | recommender_system_test | B | 2877 | 43.0 | 681 | 43.0 | 1949 | 64.0 | 928 | 32.0 |
В тесте 45% не активных пользователей, при это не равномерно расределеных по базе.
Видим что есть проблема в группе А у 36% пользователей со входом в приложение, в группе В все гораздо хуже, мало того что они набрали изначально на 7% меньше пользователей, так еще не активных 64% 2/3 пользователей не заходили в приложение.
посмотрим их данные поближе:
not_active_rst = list(set(rst.user_id.unique())-set(final_ab_events_crop.user_id.unique()))
not_active_rst_df = rst.query('user_id in @not_active_rst')
not_active_rst_df = not_active_rst_df.merge(final_ab_new_users, on='user_id')
not_active_rst_df.head()
| user_id | group | first_date | region | device | |
|---|---|---|---|---|---|
| 0 | 482F14783456D21B | B | 2020-12-14 | EU | PC |
| 1 | 057AB296296C7FC0 | B | 2020-12-17 | EU | iPhone |
| 2 | E9FA12FAE3F5769C | B | 2020-12-14 | EU | Android |
| 3 | FDD0A1016549D707 | A | 2020-12-13 | EU | PC |
| 4 | 547E99A7BDB0FCE9 | A | 2020-12-12 | EU | iPhone |
revision_ab(not_active_rst_df, col=['region','device'])
Распределение по группам
| index | group | ratio | |
|---|---|---|---|
| 0 | B | 1949 | 64.0 |
| 1 | A | 1077 | 36.0 |
Процентное соотношение колонки region :
| index | region | |
|---|---|---|
| 0 | EU | 95.0 |
| 1 | N.America | 3.0 |
| 2 | APAC | 1.0 |
| 3 | CIS | 1.0 |
Распределение колонки region распределение по группам:
| region | APAC | CIS | EU | N.America |
|---|---|---|---|---|
| group | ||||
| A | 9 | 6 | 1030 | 32 |
| B | 18 | 19 | 1840 | 72 |
Процентное соотношение колонки region распределение по группам:
| region | APAC | CIS | EU | N.America |
|---|---|---|---|---|
| group | ||||
| A | 33.0 | 24.0 | 36.0 | 31.0 |
| B | 67.0 | 76.0 | 64.0 | 69.0 |
Процентное соотношение колонки device :
| index | device | |
|---|---|---|
| 0 | Android | 45.0 |
| 1 | PC | 25.0 |
| 2 | iPhone | 21.0 |
| 3 | Mac | 9.0 |
Распределение колонки device распределение по группам:
| device | Android | Mac | PC | iPhone |
|---|---|---|---|---|
| group | ||||
| A | 475 | 103 | 285 | 214 |
| B | 883 | 184 | 469 | 413 |
Процентное соотношение колонки device распределение по группам:
| device | Android | Mac | PC | iPhone |
|---|---|---|---|---|
| group | ||||
| A | 35.0 | 36.0 | 38.0 | 34.0 |
| B | 65.0 | 64.0 | 62.0 | 66.0 |
Распределение колонок region, device распределение по группам:
| region | APAC | CIS | EU | N.America | |||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| device | Android | PC | iPhone | Android | Mac | PC | iPhone | Android | Mac | PC | iPhone | Android | Mac | PC | iPhone |
| group | |||||||||||||||
| A | 8.0 | 1.0 | NaN | 1.0 | NaN | 1.0 | 4.0 | 451.0 | 99.0 | 275.0 | 205.0 | 15.0 | 4.0 | 8.0 | 5.0 |
| B | 14.0 | 2.0 | 2.0 | 7.0 | 1.0 | 8.0 | 3.0 | 823.0 | 176.0 | 445.0 | 396.0 | 39.0 | 7.0 | 14.0 | 12.0 |
Процентное соотношение колонок region, device распределение по группам:
| region | APAC | CIS | EU | N.America | |||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| device | Android | PC | iPhone | Android | Mac | PC | iPhone | Android | Mac | PC | iPhone | Android | Mac | PC | iPhone |
| group | |||||||||||||||
| A | 36.0 | 33.0 | NaN | 12.0 | NaN | 11.0 | 57.0 | 35.0 | 36.0 | 38.0 | 34.0 | 28.0 | 36.0 | 36.0 | 29.0 |
| B | 64.0 | 67.0 | 100.0 | 88.0 | 100.0 | 89.0 | 43.0 | 65.0 | 64.0 | 62.0 | 66.0 | 72.0 | 64.0 | 64.0 | 71.0 |
нет четкого понимания чтобы можно было сказать на каком то проблемном девайсе или регионе. но проблема явно присутсвует что при расределение групп что со входом после регистрации.
посотрим есть ли токая проблема с пользователями из второго теста.
not_active_iet = (
set(final_ab_participants.query('ab_test!="recommender_system_test"').user_id.unique()) -
set(final_ab_events.user_id.unique())
)
print(len(final_ab_participants.query('ab_test!="recommender_system_test"').user_id.unique()),'>',len(not_active_iet))
print(round(len(not_active_iet) / len(final_ab_participants.query('ab_test!="recommender_system_test"').user_id.unique())*100),'%')
11567 > 717 6 %
Да тоже есть не активные пользователи, но их всего 6%.
Нужно разбараться разработчикам теста что пошло не так во время набора данных для этого теста.
ab_project_marketing_events.sort_values('start_dt')
| name | regions | start_dt | finish_dt | |
|---|---|---|---|---|
| 6 | Chinese New Year Promo | APAC | 2020-01-25 | 2020-02-07 |
| 1 | St. Valentine's Day Giveaway | EU, CIS, APAC, N.America | 2020-02-14 | 2020-02-16 |
| 8 | International Women's Day Promo | EU, CIS, APAC | 2020-03-08 | 2020-03-10 |
| 2 | St. Patric's Day Promo | EU, N.America | 2020-03-17 | 2020-03-19 |
| 3 | Easter Promo | EU, CIS, APAC, N.America | 2020-04-12 | 2020-04-19 |
| 7 | Labor day (May 1st) Ads Campaign | EU, CIS, APAC | 2020-05-01 | 2020-05-03 |
| 9 | Victory Day CIS (May 9th) Event | CIS | 2020-05-09 | 2020-05-11 |
| 11 | Dragon Boat Festival Giveaway | APAC | 2020-06-25 | 2020-07-01 |
| 4 | 4th of July Promo | N.America | 2020-07-04 | 2020-07-11 |
| 13 | Chinese Moon Festival | APAC | 2020-10-01 | 2020-10-07 |
| 12 | Single's Day Gift Promo | APAC | 2020-11-11 | 2020-11-12 |
| 5 | Black Friday Ads Campaign | EU, CIS, APAC, N.America | 2020-11-26 | 2020-12-01 |
| 0 | Christmas&New Year Promo | EU, N.America | 2020-12-25 | 2021-01-03 |
| 10 | CIS New Year Gift Lottery | CIS | 2020-12-30 | 2021-01-07 |
видим что не одно мероприятие не пересеклось с исследуемым периодом.(с 7 по 21 декабря 2020)
в связи с условиями задачи что "за 14 дней с момента регистрации пользователи покажут улучшение каждой метрики не менее, чем на 10%" уберем лишний лайфтайм у пользователей.
final_ab_events_crop['lifetime'] = (
final_ab_events_crop['event_dt'] - final_ab_events_crop['first_date']
).dt.days
print('До',final_ab_events_crop.shape[0])
final_ab_events_crop = final_ab_events_crop.query('lifetime <= 14')
print('После ',final_ab_events_crop.shape[0])
final_ab_events_crop.head()
До 24186 После 23724
| user_id | event_dt | event_name | details | dt | week_day | group | first_date | region | device | lifetime | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 831887FE7F2D6CBA | 2020-12-07 06:50:29 | purchase | 4.99 | 2020-12-07 | 0 | A | 2020-12-07 | EU | Android | 0 |
| 1 | 831887FE7F2D6CBA | 2020-12-09 02:19:17 | purchase | 99.99 | 2020-12-09 | 2 | A | 2020-12-07 | EU | Android | 2 |
| 2 | 831887FE7F2D6CBA | 2020-12-07 06:50:30 | product_cart | NaN | 2020-12-07 | 0 | A | 2020-12-07 | EU | Android | 0 |
| 3 | 831887FE7F2D6CBA | 2020-12-08 10:52:27 | product_cart | NaN | 2020-12-08 | 1 | A | 2020-12-07 | EU | Android | 1 |
| 4 | 831887FE7F2D6CBA | 2020-12-09 02:19:17 | product_cart | NaN | 2020-12-09 | 2 | A | 2020-12-07 | EU | Android | 2 |
хорошо идем дальше.
evens_user = final_ab_events_crop.groupby(['group']).agg({'user_id':'nunique','dt':'count'}).reset_index()
evens_user['evens_user'] = round(evens_user['dt'] / evens_user['user_id'],2)
evens_user
| group | user_id | dt | evens_user | |
|---|---|---|---|---|
| 0 | A | 2747 | 18643 | 6.79 |
| 1 | B | 928 | 5081 | 5.48 |
У группы А в среднем почти 7 событий, и В почти 6 события напользователя. Посмотрим медиану:
final_ab_events_crop.groupby(['user_id','group']).agg({'event_name':'count'}).reset_index().groupby('group').agg({'event_name':'median'})
| event_name | |
|---|---|
| group | |
| A | 6 |
| B | 4 |
по медиване у А 6 у В 5 на каждого пользователя.
построим график распределения событий:
mean_events = final_ab_events_crop.groupby(['user_id','group']).agg({'event_name':'count'}).reset_index()
plt.figure(figsize=(14, 3))
ax = sns.boxplot(data=mean_events, x='event_name', dodge=False);
ax.set_ylabel('', size=14)
ax.set_xlabel('кол-во событий', size=14)
plt.title('График распределения кол-во событий пользователя', size=18)
plt.xlim(-2, 20);
действительно в среднем 6 событий.
проверим статистический тестом, критерий Шапиро-Уилка:
alpha = 0.05 # критический уровень статистической значимости
results = st.shapiro(mean_events['event_name'])
p_value = results[
1
] # второе значение в массиве результатов (с индексом 1) - p-value
print('p-значение: ', p_value)
if p_value < alpha:
print('Отвергаем нулевую гипотезу: распределение не нормально')
else:
print('Не получилось отвергнуть нулевую гипотезу, всё нормально')
p-значение: 1.3970945689318426e-42 Отвергаем нулевую гипотезу: распределение не нормально
Данные распределены не нормально. посчитаем 95-й и 99-й перцентили кол-ва событий у пользователей и выявим аномальных пользователей.
print(np.percentile(mean_events['event_name'], [95, 99]))
[14. 18.]
будем считать аномальных совершивших больше 14 событий.
crop_num = np.percentile(mean_events['event_name'], 95)
anom_users_tests = mean_events.query('event_name > @crop_num')['user_id']
print('Аномальных пользователей:',len(anom_users_tests))
print('До',final_ab_events_crop.user_id.nunique())
final_ab_events_crop = final_ab_events_crop.query('user_id not in @anom_users_tests')
print('после',final_ab_events_crop.user_id.nunique())
Аномальных пользователей: 176 До 3675 после 3499
Кол-во событий по дням:
dt_users = (
final_ab_events_crop.pivot_table(index='dt',columns='group', values='user_id', aggfunc='count')
.plot(
y=list(final_ab_events_crop['group'].unique()),
style='o-',
grid=True,
figsize=(10, 5),
title='Распределения кол-во событий по дням и по группам',
)
)
dt_users.set_xlabel('дата')
dt_users.set_ylabel('кол-во')
plt.legend(loc='center left', bbox_to_anchor=(1.02, 0.5));#вынос легенды за пределы таблицы
mon_ticks(final_ab_events_crop['dt'].sort_values().unique())
plt.show()
видим что с 7 по 10 декабря были примерно одинаковы (270 событий), затем к 13 декабря в группе В события ниже 250 событий.
кол-во событий резко растет у группы А 14 декабря свыше тысячи событий, и дальнейший рост до пика 21 декабря 2000 событий.
затем падение к 28 декабря до 500 событий. группа В тоже имеет небольшие калебания повторяищие группу А только гораздо в мешьшем диапозоне (100-450 событий) для общей картины нужно посмотреть рост пользователей в группах.
dt_users = (
final_ab_events_crop.pivot_table(index='dt',columns='group', values='user_id', aggfunc='nunique')
.plot(
y=list(final_ab_events_crop['group'].unique()),
style='o-',
grid=True,
figsize=(10, 5),
title='Распределения кол-во пользователей по дням и по группам',
)
)
dt_users.set_xlabel('дата')
dt_users.set_ylabel('кол-во')
plt.legend(loc='center left', bbox_to_anchor=(1.02, 0.5));#вынос легенды за пределы таблицы
mon_ticks(final_ab_events_crop['dt'].sort_values().unique())
plt.show()
в точности аналогично кол-ву событий, тоесть ко-во событий зависят только от кол-во пользователей а средее кол-во событий в группах примерно одинаковое, посмотрим на кол-во событий в группах по дням недели:
week_list = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
dt_users = (
final_ab_events_crop.pivot_table(index='week_day',columns='group', values='user_id', aggfunc='count')
.plot(
y=list(final_ab_events_crop['group'].unique()),
style='o-',
grid=True,
figsize=(10, 5),
title='Распределения кол-во пользователей по дням и по группам',
)
)
dt_users.set_xlabel('дата')
dt_users.set_ylabel('кол-во')
plt.legend(loc='center left', bbox_to_anchor=(1.02, 0.5));#вынос легенды за пределы таблицы
plt.xticks(final_ab_events_crop['week_day'].sort_values().unique(), week_list)
plt.show()
В начале недели разрыв почти 4 кратный(3900 / 1200), но к среде разрыв уменьшается (2500 / 1000) в основном за счет сокращения в группе А, далее группа А стабильна а В еще уменьшается и к концу недели разрыв в 4 раза (2500 / 600) в пользу группы А.
Посотрим как по дням разделяются конкретные события в группах:
plt.figure(figsize=(16, 6))
ax1 = plt.subplot(1, 2, 1)
dt_users = (
final_ab_events_crop.query('group == "A"').pivot_table(index='dt',columns='event_name', values='user_id', aggfunc='count')
.plot(
y=list(final_ab_events_crop['event_name'].unique()),
style='o-',
grid=True,
title='Распределения кол-во событий по дням и по типу события в группе А',
legend=False,
ax=ax1
)
)
dt_users.set_xlabel('дата')
dt_users.set_ylabel('кол-во')
mon_ticks(final_ab_events_crop['dt'].sort_values().unique())
ax2 = plt.subplot(1, 2, 2, sharey=ax1)
dt_userss = (
final_ab_events_crop.query('group == "B"').pivot_table(index='dt',columns='event_name', values='user_id', aggfunc='count')
.plot(
y=list(final_ab_events_crop['event_name'].unique()),
style='o-',
grid=True,
title='Распределения кол-во событий по дням и по типу события в группе B',
ax=ax2
)
)
dt_userss.set_xlabel('дата')
dt_userss.set_ylabel('кол-во')
plt.legend(loc='center left', bbox_to_anchor=(-0.24, 0.5));#вынос легенды за пределы таблицы
mon_ticks(final_ab_events_crop['dt'].sort_values().unique())
plt.show()
тут нет явных акцентов все в принцепе повторяет общую динамику.
посмотрим динамику пользователей по регионам:
plt.figure(figsize=(16, 6))
ax1 = plt.subplot(1, 2, 1)
dt_users = (
final_ab_events_crop.query('group == "A"').pivot_table(index='dt',columns='region', values='user_id', aggfunc='nunique')
.plot(
y=list(final_ab_events_crop['region'].unique()),
style='o-',
grid=True,
title='Распределения кол-во пользователей по дням и по регионам в группе А',
legend=False,
ax=ax1
)
)
dt_users.set_xlabel('дата')
dt_users.set_ylabel('кол-во')
mon_ticks(final_ab_events_crop['dt'].sort_values().unique())
ax2 = plt.subplot(1, 2, 2, sharey=ax1)
dt_userss = (
final_ab_events_crop.query('group == "B"').pivot_table(index='dt',columns='region', values='user_id', aggfunc='nunique')
.plot(
y=list(final_ab_events_crop['region'].unique()),
style='o-',
grid=True,
title='Распределения кол-во пользователей по дням и по регионам в группе B',
ax=ax2
)
)
dt_userss.set_xlabel('дата')
dt_userss.set_ylabel('кол-во')
plt.legend(loc='center left', bbox_to_anchor=(-0.24, 0.5));#вынос легенды за пределы таблицы
mon_ticks(final_ab_events_crop['dt'].sort_values().unique())
plt.show()
видим что скачек происходит от резкого раста пользователей Европы с 150 до 400 14 декабря и до 750 21 декабря и дальше идет на спад. Возможное влияние праздников.
посмотрим динамику по устройствам:
plt.figure(figsize=(16, 6))
ax1 = plt.subplot(1, 2, 1)
dt_users = (
final_ab_events_crop.query('group == "A"').pivot_table(index='dt',columns='device', values='user_id', aggfunc='nunique')
.plot(
y=list(final_ab_events_crop['device'].unique()),
style='o-',
grid=True,
title='Распределения кол-во пользователей по дням и по типу устройства в группе А',
legend=False,
ax=ax1
)
)
dt_users.set_xlabel('дата')
dt_users.set_ylabel('кол-во')
mon_ticks(final_ab_events_crop['dt'].sort_values().unique())
ax2 = plt.subplot(1, 2, 2, sharey=ax1)
dt_userss = (
final_ab_events_crop.query('group == "B"').pivot_table(index='dt',columns='device', values='user_id', aggfunc='nunique')
.plot(
y=list(final_ab_events_crop['device'].unique()),
style='o-',
grid=True,
title='Распределения кол-во пользователей по дням и по типу устройства в группе B',
ax=ax2
)
)
dt_userss.set_xlabel('дата')
dt_userss.set_ylabel('кол-во')
plt.legend(loc='center left', bbox_to_anchor=(-0.24, 0.5));#вынос легенды за пределы таблицы
mon_ticks(final_ab_events_crop['dt'].sort_values().unique())
plt.show()
видим анологичную картину по росту пользователей но рспределенно все в соотношении нет выбивающегося устройства как при делении на регионы.
для сравнения посотрим на тот же промежуток но у остальных пользователей не принимавших участия в тесте:
dt_users = (
final_not_ab_events_crop.pivot_table(index='dt', values='user_id', aggfunc='nunique')
.plot(
style='o-',
grid=True,
figsize=(10, 5),
title='Распределения кол-во событий по дням и по группам',
)
)
dt_users.set_xlabel('дата')
dt_users.set_ylabel('кол-во')
plt.legend(loc='center left', bbox_to_anchor=(1.02, 0.5));#вынос легенды за пределы таблицы
mon_ticks(final_ab_events_crop['dt'].sort_values().unique())
plt.show()
тот же график с разрезо по типам события:
dt_users = (
final_not_ab_events_crop.pivot_table(index='dt',columns='event_name', values='user_id', aggfunc='count')
.plot(
y=list(final_ab_events_crop['event_name'].unique()),
style='o-',
grid=True,
figsize=(10, 5),
title='Распределения кол-во событий по дням вне нашего теста',
)
)
dt_users.set_xlabel('дата')
dt_users.set_ylabel('кол-во')
plt.legend(loc='center left', bbox_to_anchor=(1.02, 0.5));#вынос легенды за пределы таблицы
mon_ticks(final_ab_events_crop['dt'].sort_values().unique())
plt.show()
на этих данных похожая форма с группой А из нашего теста, пики 14 и 21 по понедельникам. возможно этот всплеск относится к подготовке к рождеству 25 декабря по григорианскому календарю, все покупают подарки. Так что данные даже без пересечениями с маркетинговыми акциями подвергается сезонному искажению.
Так же обротим внимание на резкий скачек кол-во событий в группе А 14 декабря,скорее всего связан с резким увеличением кол-во пользователей в этой группе.
Перед тестированием нужно убедится:
1) мы отбросили анамальных пользователей 2) иструмент работает не совсем верно поделив аудиторию теста на 57 / 43% (до фильтрации с активными пользователями) 3) данные отправляются корректно(пропусков и дубликатов мы не обнаружили)
по части критериев мы не можем переходить к анализу А/В теста так как он не соответствует части критериев.
берем во внимание все выше перечисленное и переходим к тесту.
функция для статистической проверки гипотез методом z-тест для пропорций :
def equality_check(t1, t2, s1, s2, alpha):
successes = np.array([s1, s2])
trials = np.array([t1, t2])
# пропорция успехов в первой группе:
p1 = successes[0]/trials[0]
# пропорция успехов во второй группе:
p2 = successes[1]/trials[1]
# пропорция успехов в комбинированном датасете:
p_combined = (successes[0] + successes[1]) / (trials[0] + trials[1])
# разница пропорций в датасетах
difference = p1 - p2
# считаем статистику в ст.отклонениях стандартного нормального распределения
z_value = difference / (p_combined * (1 - p_combined) * (1/trials[0] + 1/trials[1])) ** 0.5
# задаем стандартное нормальное распределение (среднее 0, ст.отклонение 1)
distr = st.norm(0, 1)
p_value = (1 - distr.cdf(abs(z_value))) * 2
print('p-значение: ', p_value)
if p_value < alpha:
print('Отвергаем нулевую гипотезу: между долями есть значимая разница')
else:
print(
'Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными'
)
функция формирования пользовательских сессий:
def session(da):
#групперуем по пользователю и номеру сессии добавляя время начала и конца сессии и названия события первого и последнего.
session = (
da
.pivot_table(index=['user_id','session_id'], values=['event_dt','event_name'], aggfunc=['first','last'])
.reset_index()
)
#групперуем по пользователю и номеру сессии считаем общее кол-во событий за сессию и уникальных событий за сессию
counts_events = (
da
.pivot_table(index=['user_id','session_id'], values=['event_name'], aggfunc=['count','nunique'])
.reset_index()
)
#функция удаление двухуровневых названий колонок
def del_duble_col_name(df1):
name_col = pd.DataFrame.from_records(df1.columns) #берем двухуровневые названия колонок
name_col['col'] = name_col[0] + name_col[1].str.replace('event', '_event') #соединяем их в один уровень
df1.columns = name_col['col'].to_list()#назначаем колонкам один уровень с названиями
return df1#возвращаем таблицу
#применяем функцию
del_duble_col_name(session)
del_duble_col_name(counts_events)
#добавляем колонку с датой без времени
session['dt'] = pd.to_datetime(session['first_event_dt']).dt.date
#добавляем колонку с днем недели
session['week_day'] = pd.to_datetime(session['first_event_dt']).dt.dayofweek
#добавляем колонку с продолжительностью
session['duration_m'] = (session['last_event_dt'] - session['first_event_dt']).astype('timedelta64[m]')
#добавляем колонку с браузерами
session = pd.merge(session, final_ab_events_crop[['user_id','group','region','device']] ,on='user_id', how='left')
#добавляем колонку с кол-вом событий
session = pd.merge(session, counts_events, on=['user_id','session_id'], how='left')
return session
# функция для создания пользовательских профилей
def get_profiles(sessions, orders, events, event_names=[]):
# находим параметры первых посещений
profiles = (
sessions.sort_values(by=['user_id', 'first_event_dt'])
.groupby('user_id')
.agg(
{
'first_event_dt': 'first',
'group': 'first',
'device': 'first',
'region': 'first',
}
)
.rename(columns={'first_event_dt': 'first_ts'})
.reset_index()
)
# для когортного анализа определяем дату первого посещения
# и первый день месяца, в который это посещение произошло
profiles['dt'] = profiles['first_ts'].dt.date
profiles['month'] = profiles['first_ts'].astype('datetime64[M]')
# добавляем признак платящих пользователей
profiles['payer'] = profiles['user_id'].isin(orders['user_id'].unique())
# добавляем флаги для всех событий из event_names
for event in event_names:
if event in events['event_name'].unique():
profiles[event] = profiles['user_id'].isin(
events.query('event_name == @event')['user_id'].unique()
)
return profiles
def group_cohort(df, gr): # функция для формирования таблиц соотношения платящих от всех пользователей
gc = (pd.merge(
df.groupby(gr).agg({'user_id':'nunique'}), # группируем по параметру всех
df.loc[df['payer']==True].groupby(gr).agg({'user_id':'nunique'}), # группируем по параметру платящих
on=gr, how='outer') #объединяем
.rename(columns={'user_id_x': 'all_users', 'user_id_y': 'payer_users'}) # переменовываем колонки
.sort_values('all_users',ascending=False) # сортируем по количеству пользователей
.reset_index() # сбрасываем индекс
)
gc['percent_payers'] = round(gc['payer_users'] / gc['all_users'] * 100, 2) #вычисляем процент платящих
gc['percent_users'] = round(
gc['all_users'] / gc['all_users'].sum() * 100 #вычисляем процент пользователей от всех пользователей
, 2)
return gc
# функция для расчёта удержания
def get_retention(
profiles,
sessions,
observation_date,
horizon_days,
dimensions=[],
ignore_horizon=False,
):
# добавляем столбец payer в передаваемый dimensions список
dimensions = ['payer'] + dimensions
# исключаем пользователей, не «доживших» до горизонта анализа
last_suitable_acquisition_date = observation_date
if not ignore_horizon:
last_suitable_acquisition_date = observation_date - timedelta(
days=horizon_days - 1
)
result_raw = profiles.query('dt <= @last_suitable_acquisition_date')
# собираем «сырые» данные для расчёта удержания
result_raw = result_raw.merge(
sessions[['user_id', 'first_event_dt']], on='user_id', how='left'
)
result_raw['lifetime'] = (
result_raw['first_event_dt'] - result_raw['first_ts']
).dt.days
# функция для группировки таблицы по желаемым признакам
def group_by_dimensions(df, dims, horizon_days):
result = df.pivot_table(
index=dims, columns='lifetime', values='user_id', aggfunc='nunique'
)
cohort_sizes = (
df.groupby(dims)
.agg({'user_id': 'nunique'})
.rename(columns={'user_id': 'cohort_size'})
)
result = cohort_sizes.merge(result, on=dims, how='left').fillna(0)
result = result.div(result['cohort_size'], axis=0)
result = result[['cohort_size'] + list(range(horizon_days))]
result['cohort_size'] = cohort_sizes
return result
# получаем таблицу удержания
result_grouped = group_by_dimensions(result_raw, dimensions, horizon_days)
# получаем таблицу динамики удержания
result_in_time = group_by_dimensions(
result_raw, dimensions + ['dt'], horizon_days
)
# возвращаем обе таблицы и сырые данные
return result_raw, result_grouped, result_in_time
# функция для сглаживания фрейма
def filter_data(df, window):
# для каждого столбца применяем скользящее среднее
for column in df.columns.values:
df[column] = df[column].rolling(window).mean()
return df
# функция для визуализации удержания
def plot_retention(retention, retention_history, horizon, window=7):
# задаём размер сетки для графиков
plt.figure(figsize=(15, 10))
# исключаем размеры когорт и удержание первого дня
retention = retention.drop(columns=['cohort_size', 0])
# в таблице динамики оставляем только нужный лайфтайм
retention_history = retention_history.drop(columns=['cohort_size'])[
[horizon - 1]
]
# если в индексах таблицы удержания только payer,
# добавляем второй признак — cohort
if retention.index.nlevels == 1:
retention['cohort'] = 'All users'
retention = retention.reset_index().set_index(['cohort', 'payer'])
# в таблице графиков — два столбца и две строки, четыре ячейки
# в первой строим кривые удержания платящих пользователей
ax1 = plt.subplot(2, 2, 1)
retention.query('payer == True').droplevel('payer').T.plot(
grid=True, ax=ax1
)
plt.legend()
plt.xlabel('Лайфтайм')
plt.title('Удержание платящих пользователей')
# во второй ячейке строим кривые удержания неплатящих
# вертикальная ось — от графика из первой ячейки
ax2 = plt.subplot(2, 2, 2, sharey=ax1)
retention.query('payer == False').droplevel('payer').T.plot(
grid=True, ax=ax2
)
plt.legend()
plt.xlabel('Лайфтайм')
plt.title('Удержание неплатящих пользователей')
# в третьей ячейке — динамика удержания платящих
ax3 = plt.subplot(2, 2, 3)
# получаем названия столбцов для сводной таблицы
columns = [
name
for name in retention_history.index.names
if name not in ['dt', 'payer']
]
# фильтруем данные и строим график
filtered_data = retention_history.query('payer == True').pivot_table(
index='dt', columns=columns, values=horizon - 1, aggfunc='mean'
)
filter_data(filtered_data, window).plot(grid=True, ax=ax3)
plt.xlabel('Дата привлечения')
plt.title(
'Динамика удержания платящих пользователей на {}-й день'.format(
horizon
)
)
# в чётвертой ячейке — динамика удержания неплатящих
ax4 = plt.subplot(2, 2, 4, sharey=ax3)
# фильтруем данные и строим график
filtered_data = retention_history.query('payer == False').pivot_table(
index='dt', columns=columns, values=horizon - 1, aggfunc='mean'
)
filter_data(filtered_data, window).plot(grid=True, ax=ax4)
plt.xlabel('Дата привлечения')
plt.title(
'Динамика удержания неплатящих пользователей на {}-й день'.format(
horizon
)
)
plt.tight_layout()
plt.show()
подготовим данные:
groups_sizes = final_ab_events_crop.groupby('group').agg({'user_id':'nunique'}).T.reset_index()
groups_sizes.columns.name=''
groups_sizes.rename(columns = {'index':'event_name'}, inplace = True)
groups_sizes['event_name']= 'All'
groups_sizes
| event_name | A | B | |
|---|---|---|---|
| 0 | All | 2590 | 909 |
Сформулируем гипотезы:
equality_check(
groups_sizes['A'][0]+groups_sizes['B'][0],
groups_sizes['A'][0]+groups_sizes['B'][0],
groups_sizes['A'][0],
groups_sizes['B'][0],
0.05
)
p-значение: 0.0 Отвергаем нулевую гипотезу: между долями есть значимая разница
очевидный вывод, предварительно мы предпологали такой исход.
Посмотрим распределение в группах по событиям:
groups_users = (
final_ab_events_crop.pivot_table( index='event_name',columns='group', values='user_id', aggfunc='nunique')
.sort_values('A',ascending=False)
.reset_index()
)
groups_users.columns.name=''
groups_users = groups_users.reindex(index = [0, 1, 3, 2]).reset_index(drop=False)
groups_users['other'] = final_not_ab_events_crop.pivot_table( index='event_name', values='user_id', aggfunc='nunique')['user_id'].to_list()
groups_users
| index | event_name | A | B | other | |
|---|---|---|---|---|---|
| 0 | 0 | login | 2590 | 908 | 49757 |
| 1 | 1 | product_page | 1632 | 505 | 16445 |
| 2 | 3 | product_cart | 714 | 241 | 33119 |
| 3 | 2 | purchase | 757 | 242 | 16654 |
для удобства напишем функцию для перебора ячеек:
def cicle_event(df, group1, group2, alpha):
for i in range(1, len(df[group1])):
print(group1,'&', group2, df['event_name'][i])
equality_check(
df[group1][0],
df[group2][0],
df[group1][i],
df[group2][i],
alpha)
print()
выставим границу альфа в 5%, но чтобы снизить групповую вероятность ошибки первого рода и скорректировать требуемые уровни значимости установим уровень равный количеству тестируемых групп применим метод Холма и Шидака
holm_shidak_alpha = round(1 - (1 - 0.05)**(1/3),3)
holm_shidak_alpha
0.017
Для проверки используем нашу функцию z-тест для пропорций, сформулируем гипотезы:
cicle_event(groups_users, 'A', 'B', holm_shidak_alpha)
A & B product_page p-значение: 8.396691117362742e-05 Отвергаем нулевую гипотезу: между долями есть значимая разница A & B product_cart p-значение: 0.5505246231934882 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными A & B purchase p-значение: 0.13924623799795133 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
Cтатистически значимые отличия между группами есть в product_page, а вот в двух событиях product_cart,purchase нет оснований чтобы считать доли разными.
так как мы имеем данные по выручке можем провести дополнительный анализ, подготовим данные по заказам:
orders = final_ab_events_crop.query('event_name == "purchase"').groupby(['user_id','dt']).agg({'details': 'sum','group': 'max','region':'count'}).reset_index()
orders = orders.rename(columns={'dt':'date','region':'orders','details':'revenue'})
orders.head()
| user_id | date | revenue | group | orders | |
|---|---|---|---|---|---|
| 0 | 0010A1C096941592 | 2020-12-17 | 4.99 | A | 1 |
| 1 | 0010A1C096941592 | 2020-12-19 | 4.99 | A | 1 |
| 2 | 0010A1C096941592 | 2020-12-21 | 4.99 | A | 1 |
| 3 | 0010A1C096941592 | 2020-12-23 | 9.99 | A | 1 |
| 4 | 005E096DBD379BCF | 2020-12-21 | 99.99 | B | 1 |
данные по ссумме заказов и средней суммы на одного пользователя:
df_revenue = orders.groupby('group').agg({'user_id':'nunique','revenue':'sum'})
df_revenue['user_ratio'] = round(df_revenue['user_id']/df_revenue['user_id'].sum()*100)
df_revenue['revenue_ratio'] = round(df_revenue['revenue']/df_revenue['revenue'].sum()*100)
df_revenue['one_user'] = round(df_revenue['revenue']/df_revenue['user_id'])
df_revenue
| user_id | revenue | user_ratio | revenue_ratio | one_user | |
|---|---|---|---|---|---|
| group | |||||
| A | 757 | 51949.79 | 76.0 | 79.0 | 69.0 |
| B | 242 | 13604.38 | 24.0 | 21.0 | 56.0 |
Наблюдаем:
В группе В все показатели ниже группы А.
подготовим данные по посещениям:
visitors = final_ab_events_crop.groupby(['dt','group']).agg({'user_id':'nunique'}).reset_index()
visitors = visitors.rename(columns={'dt':'date','user_id':'visitors'})
visitors.head()
| date | group | visitors | |
|---|---|---|---|
| 0 | 2020-12-07 | A | 148 |
| 1 | 2020-12-07 | B | 161 |
| 2 | 2020-12-08 | A | 154 |
| 3 | 2020-12-08 | B | 111 |
| 4 | 2020-12-09 | A | 167 |
# строим таблицу с датой и группой
dates_groups = orders[['date', 'group']].drop_duplicates()
# агриггируем данные по заказам по дате и группе
orders_aggregated = dates_groups.apply(
lambda x: orders[
np.logical_and(
orders['date'] <= x['date'], orders['group'] == x['group']
)
].agg(
{
'date': 'max',
'group': 'max',
'orders': 'sum',
'user_id': 'nunique',
'revenue': 'sum',
}
),
axis=1,
).sort_values(by=['date', 'group'])
# агриггируем данные по посещениям по дате и группе
visitors_aggregated = dates_groups.apply(
lambda x: visitors[
np.logical_and(
visitors['date'] <= x['date'], visitors['group'] == x['group']
)
].agg(
{
'date': 'max',
'group': 'max',
'visitors': 'sum'
}
),
axis=1,
).sort_values(by=['date', 'group'])
# объединяем полученные таблицы по заказам и посещениям
cumulative_data = orders_aggregated.merge(
visitors_aggregated, left_on=['date', 'group'], right_on=['date', 'group']
)
# переименовываем столбцы
cumulative_data.columns = [
'date',
'group',
'orders',
'buyers',
'revenue',
'visitors',
]
# Отделим группу А из всей таблицы
cumulative_revenue_a = cumulative_data[cumulative_data['group']=='A'][['date','revenue', 'orders']]
# Отделим группу B из всей таблицы
cumulative_revenue_b = cumulative_data[cumulative_data['group']=='B'][['date','revenue', 'orders']]
fig = plt.figure(figsize=(14, 6)) # Размер графика
# Строим график выручки группы А
plt.plot(cumulative_revenue_a['date'], cumulative_revenue_a['revenue'], label='A')
# Строим график выручки группы B
plt.plot(cumulative_revenue_b['date'], cumulative_revenue_b['revenue'], label='B')
plt.title('График кумулятивной выручки по группам')# Заголовок графика
plt.xticks(rotation=0) # поворот подписей
# Добавляем подписи к осям:
plt.xlabel('Дата выручки')
plt.ylabel('Сумма выручки')
plt.ticklabel_format(axis='y', style='plain', scilimits=(0,0))# отображение оси У целым числом
plt.legend();
Группа А стабильно выше В, присутствует плавное увеличение выручки с 13 числа.
Возможно все тоже влияние увеличения кол-во пользователей в группе.(так как в группе А в 3 раза больше пользователей)
fig = plt.figure(figsize=(14, 6)) # Размер графика
# Строим график среднего чека группы А
plt.plot(cumulative_revenue_a['date'], cumulative_revenue_a['revenue']/cumulative_revenue_a['orders'], label='A')
# Строим график среднего чека группы В
plt.plot(cumulative_revenue_b['date'], cumulative_revenue_b['revenue']/cumulative_revenue_b['orders'], label='B')
plt.title('График кумулятивного среднего чека по группам')# Заголовок графика
# Добавляем подписи к осям:
plt.xlabel('Дата')
plt.ylabel('Сумма среднего чека')
plt.legend();
При всех лучших показателях группы А, здесь наблюдаем плавный рост с 12 декабря группы В для среднего чека
от 14 до 24 едениц к 17 декабря. группа А показывает средений чек в диапозоне 25-27, идет плавное снижение с 8 декабря(27) до 28 декабря (25).
# собираем данные в одном датафрейме
merged_cumulative_revenue = cumulative_revenue_a.merge(cumulative_revenue_b, left_on='date', right_on='date', how='left', suffixes=['A', 'B'])
fig = plt.figure(figsize=(14, 6)) # Размер графика
# cтроим отношение средних чеков
plt.plot(
merged_cumulative_revenue['date'],
(
merged_cumulative_revenue['revenueB']/merged_cumulative_revenue['ordersB']
)/(
merged_cumulative_revenue['revenueA']/merged_cumulative_revenue['ordersA']
)-1
)
plt.title('График относительного изменения кумулятивного среднего чека группы B к группе A')# Заголовок графика
# Добавляем подписи к осям:
plt.xlabel('Дата')
plt.ylabel('Различия среднего чека')
# добавляем ось X
plt.axhline(y=0.3, color='grey', linestyle='--')
plt.axhline(y=0, color='black', linestyle='--');
На графике видим резкие скачки между сегментами, возможно это влияние аномальных заказов.
# Отделим группу А из всей таблицы
cumulative_orders_a = cumulative_data[cumulative_data['group']=='A'][['date', 'orders', 'visitors']]
# Отделим группу B из всей таблицы
cumulative_orders_b = cumulative_data[cumulative_data['group']=='B'][['date', 'orders', 'visitors']]
fig = plt.figure(figsize=(14, 6)) # Размер графика
# Строим график среднего количества заказов группы А
plt.plot(cumulative_orders_a['date'], cumulative_orders_a['orders']/cumulative_orders_a['visitors'], label='A')
# Строим график среднего количества заказов группы B
plt.plot(cumulative_orders_b['date'], cumulative_orders_b['orders']/cumulative_orders_b['visitors'], label='B')
plt.title('График кумулятивного среднего количества заказов по группам')# Заголовок графика
# Добавляем подписи к осям:
plt.xlabel('Дата')
plt.ylabel('Кол-во заказов')
plt.grid() # Добавление сетки
plt.legend();
# собираем данные в одном датафрейме
merged_cumulative_orders = cumulative_orders_a.merge(cumulative_orders_b, left_on='date', right_on='date', how='left', suffixes=['A', 'B'])
fig = plt.figure(figsize=(14, 6)) # Размер графика
# cтроим отношение средних чеков
plt.plot(
merged_cumulative_orders['date'],
(
merged_cumulative_orders['visitorsB']/merged_cumulative_orders['ordersB']
)/(
merged_cumulative_orders['visitorsA']/merged_cumulative_orders['ordersA']
)-1
)
plt.title('График относительного изменения кумулятивного среднего количества заказов группы B к группе A')# Заголовок графика
# Добавляем подписи к осям:
plt.xlabel('Дата')
plt.ylabel('Относительное изменение среднего кол-ва заказов')
plt.grid() # Добавление сетки
# добавляем ось X
plt.axhline(y=0.20, color='grey', linestyle='--')
plt.axhline(y=0, color='black', linestyle='--');
видим что с начала наблюдаемого периода лидирует группа А на 15% , с 8 числа все меняется и сначала выравнивается разница в 0 затем с 13 начинается плавный рост группы В до 12%.
orders_by_users = (
orders.groupby(['user_id','group'], as_index=False)
.agg({'orders': 'sum'}).reset_index()
)
fig = plt.figure(figsize=(14, 6)) # Размер графика
sns.scatterplot(data=orders_by_users, x='index', y='orders', hue='group')
# Добавляем подписи к осям:
plt.xlabel('Пользователь')
plt.ylabel('Кол-во заказов')
plt.show;
Видем что большинство заказов не превышают 5 заказов на пользователя.
print(np.percentile(orders_by_users['orders'], [95, 99]))
[4. 5.]
fig = plt.figure(figsize=(14, 6)) # Размер графика
sns.scatterplot(data=orders.reset_index(), x='index', y='revenue', hue='group')
# Добавляем подписи к осям:
plt.xlabel('Заказ')
plt.ylabel('Стоимость заказа')
plt.show;
На графике видем очень аномальный заказов нет
Видим что большинство заказов не превышают 500.
print(np.percentile(orders['revenue'], [95, 99]))
[ 99.99 499.99]
Сформулируем гипотезы:
# применим тест Манна-Уитни
print('p-value: {0:.3f}'.format(
st.mannwhitneyu(
orders[orders['group']=='A']['revenue'], orders[orders['group']=='B']['revenue']
)[1]
))
# вычислим относительное различие в среднем
print('Относительное различие в среднем: {0:.3f}'.format(
orders[orders['group']=='B']['revenue'].mean()/orders[orders['group']=='A']['revenue'].mean()-1
))
p-value: 0.273 Относительное различие в среднем: -0.058
не большая относительная разница при отсутствии статистической значимости говорит о влиянии выбросов.
подготовим данные:
# отсортируем таблицу по пользователям и времени:
df = final_ab_events_crop.copy().sort_values(['user_id', 'event_dt'])
# определим длительность между событиями
diff_timestamp = df.groupby('user_id')['event_dt'].diff()
# создадим переменную 30 минут на простой для определения новой сесии - стандарт если нет других данных
time_session = np.timedelta64(30,'m')
# размечаем события превышающие пороговое значение
new_session = (diff_timestamp.isnull()) | (diff_timestamp > time_session)
# присвоим номер сессии пользователя в соответствии таблице new_session присвоив ранк и поменяем тип на число
df['session_id'] = df.loc[new_session, ['user_id', 'event_dt']].groupby('user_id').rank(method='first').astype(int)
# заполним промежеточные значения Nan на ранк в соответсвии с их порядком до новой сессии
df['session_id'] = df['session_id'].fillna(method='ffill').astype(int)
df.head()
| user_id | event_dt | event_name | details | dt | week_day | group | first_date | region | device | lifetime | session_id | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 20781 | 001064FEAAB631A1 | 2020-12-20 14:43:27 | login | NaN | 2020-12-20 | 6 | B | 2020-12-20 | EU | Android | 0 | 1 |
| 20778 | 001064FEAAB631A1 | 2020-12-20 14:43:28 | product_page | NaN | 2020-12-20 | 6 | B | 2020-12-20 | EU | Android | 0 | 1 |
| 20782 | 001064FEAAB631A1 | 2020-12-21 03:19:15 | login | NaN | 2020-12-21 | 0 | B | 2020-12-20 | EU | Android | 1 | 2 |
| 20779 | 001064FEAAB631A1 | 2020-12-21 03:19:17 | product_page | NaN | 2020-12-21 | 0 | B | 2020-12-20 | EU | Android | 1 | 2 |
| 20783 | 001064FEAAB631A1 | 2020-12-26 15:55:17 | login | NaN | 2020-12-26 | 5 | B | 2020-12-20 | EU | Android | 6 | 3 |
применим нашу функцию для определения сеансов:
sessions = session(df)
sessions.head()
| user_id | session_id | first_event_dt | first_event_name | last_event_dt | last_event_name | dt | week_day | duration_m | group | region | device | count_event_name | nunique_event_name | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 001064FEAAB631A1 | 1 | 2020-12-20 14:43:27 | login | 2020-12-20 14:43:28 | product_page | 2020-12-20 | 6 | 0.0 | B | EU | Android | 2 | 2 |
| 1 | 001064FEAAB631A1 | 1 | 2020-12-20 14:43:27 | login | 2020-12-20 14:43:28 | product_page | 2020-12-20 | 6 | 0.0 | B | EU | Android | 2 | 2 |
| 2 | 001064FEAAB631A1 | 1 | 2020-12-20 14:43:27 | login | 2020-12-20 14:43:28 | product_page | 2020-12-20 | 6 | 0.0 | B | EU | Android | 2 | 2 |
| 3 | 001064FEAAB631A1 | 1 | 2020-12-20 14:43:27 | login | 2020-12-20 14:43:28 | product_page | 2020-12-20 | 6 | 0.0 | B | EU | Android | 2 | 2 |
| 4 | 001064FEAAB631A1 | 1 | 2020-12-20 14:43:27 | login | 2020-12-20 14:43:28 | product_page | 2020-12-20 | 6 | 0.0 | B | EU | Android | 2 | 2 |
вызовем нашу функцию для создания таблицы профелей:
events = final_ab_events_crop.copy()
profiles = get_profiles(sessions, orders, events)
Вызовим нашу функцию для определения из каких регионов пользователи приходят в приложение и на какуй регион приходится больше всего платящих пользователей.
group_region = group_cohort(profiles, 'region')
group_region
| region | all_users | payer_users | percent_payers | percent_users | |
|---|---|---|---|---|---|
| 0 | EU | 3312 | 955 | 28.83 | 94.66 |
| 1 | N.America | 115 | 38 | 33.04 | 3.29 |
| 2 | APAC | 44 | 1 | 2.27 | 1.26 |
| 3 | CIS | 28 | 5 | 17.86 | 0.80 |
EU 95% от всех пользователей, из них почти 28% платящие.N.America там по ~ 3,2% пользователей от всех, из них почти по 33% платящие.APAC там по ~ 1,2% пользователей от всех, из них почти по 2,2% платящие.Germany там 0,8% пользователей из них чуть больше 18,5% платящие.Так же вызовим функцию для определения какими устройствами пользуются клиенты и какие устройства предпочитают платящие пользователи.
group_region = group_cohort(profiles, 'device')
group_region
| device | all_users | payer_users | percent_payers | percent_users | |
|---|---|---|---|---|---|
| 0 | Android | 1549 | 436 | 28.15 | 44.27 |
| 1 | PC | 918 | 254 | 27.67 | 26.24 |
| 2 | iPhone | 704 | 203 | 28.84 | 20.12 |
| 3 | Mac | 328 | 106 | 32.32 | 9.37 |
Android 44% от всех пользователей, из них чуть больше 28% платящие.PC здесь 26% пользователей от всех, из них почти 27% платящие.iPhone здесь 20,1% пользователей от всех, из них почти 28% платящие.Mac 9,4% пользователей, чуть больше 32% платящие.что происходит при делении на тестируемые группы:
group_region = group_cohort(profiles, 'group')
group_region
| group | all_users | payer_users | percent_payers | percent_users | |
|---|---|---|---|---|---|
| 0 | A | 2590 | 757 | 29.23 | 74.02 |
| 1 | B | 909 | 242 | 26.62 | 25.98 |
A 74% от всех пользователей, из них чуть больше 29% платящие.В 24,99% пользователей, из них почти 26,6% платящие.посмотри как что с удержанием:
events = None # других событий нет
horizon_days = 14
observation_date = dt(2020, 12, 28).date() # момент анализа
dimensions=['group']
retention_raw, retention_grouped, retention_history = get_retention(
profiles, sessions, observation_date, horizon_days,dimensions=dimensions
)
profiles
plot_retention(retention_grouped, retention_history, horizon_days, window=4)
Удивительно но динамика удержания и у платящих и не плотящий в обеих группах происходит схожим образом, группа А стабильно лучше группы В.
по динамике на 14 день:
До выводов нужно выделить проблемные места исследования:
По техническому заданию:
product_pageproduct_cart,purchase нет оснований чтобы считать доли разными.детали:
рекомендации:
по результатам теста: нужно остановить тест, признать его не состоявшимся слишком много недостатков после предобработки данных.